[SwiftUI] TimelineViewとCanvasでパーティクルをたくさん描画する!
2024.09.06
こんにちは。きんくまです。
SwiftUI勉強中です
つくったもの
機能
- Canvasをドラッグすることで、背景の色を変化させる
- Canvasをドラッグさせたときに、まわりに泡のパーティクルを発生させる
- パーティクルはサインカーブを描きながら上に上がっていく
- 現在のフレームレート(FPS)と泡の数を表示する
ソースコード
/// 泡
struct Bubble {
/// X座標
var x: CGFloat {
// 開始地点からサインカーブで横に移動させる
startX + sin(radian) * xRadius
}
/// X座標の開始地点
var startX: CGFloat
/// X座標をサインカーブでゆらすときの半径
var xRadius: CGFloat
/// Y座標
var y: CGFloat
/// 速度Y
let vy: CGFloat
/// サインカーブするときのラジアン
var radian: CGFloat
/// 泡を描画するときの半径
var radius: CGFloat
/// 透明度(開始時に0にして、ふわっと出現させる)
var opacity: CGFloat = 0
/// 透明度の最大値
let maxOpacity: CGFloat
/// 泡をランダム性をもたせながら作成する
static func makeBubble(x: CGFloat, y: CGFloat) -> Bubble {
let startX = x + CGFloat.random(in: -1 ... 1) * 10
let xRadius = CGFloat.random(in: 20 ... 50)
let y = y + CGFloat.random(in: -1 ... 1) * 10
let vy = CGFloat.random(in: -4 ... -1)
let radian = CGFloat.random(in: 0 ..< CGFloat.pi * 2)
let radius = 30 * CGFloat.random(in: 0.05 ... 1)
let maxOpacity = CGFloat.random(in: 0.2 ... 1)
return Bubble(startX: startX, xRadius: xRadius, y: y, vy: vy, radian: radian, radius: radius, maxOpacity: maxOpacity)
}
/// データを更新する
mutating func update() {
radian += 0.03
// 2πを超えたら0からまわすように戻す
if radian > CGFloat.pi * 2 {
radian = radian - CGFloat.pi * 2
}
// Y座標は速度Yを足す
y += vy
// 透明度をアニメーションさせながら一定の数値まであげる
if opacity <= maxOpacity {
opacity += 0.03
}
}
}
/// モデル
@Observable class ViewModel {
/// 背景色の色相
var backGroundColorHue: CGFloat = 0.5
@ObservationIgnored private var previousBackGroundColorHue: CGFloat = 0.5
/// 背景のグラデーション
var backgroundGradient: Gradient {
let startColor = Color(hue: backGroundColorHue - 0.2, saturation: 1, brightness: 1)
let centerColor = Color(hue: backGroundColorHue , saturation: 0.9, brightness: 0.6)
let endColor = Color(hue: backGroundColorHue + 0.1, saturation: 0.7, brightness: 0.2)
return Gradient(colors: [startColor, centerColor, endColor])
}
/// 泡たち
var bubbles: [Bubble] = []
/// 前回の更新日時
var previousUpdateDate = Date()
/// フレームレート
var fps: Int = 0
/// ドラッグ時
func updateOnDragChange(_ value: DragGesture.Value, canvasSize: CGSize) {
// Y方向のドラッグの値によって背景の色相を変更する
let dy = value.location.y - value.startLocation.y
// 画面の高さに応じて0から1に正規化する(扱いやすくするため)
let normalizedDy = dy / canvasSize.height
var newBackGroundColorBaseHue = previousBackGroundColorHue + normalizedDy * 0.6
if newBackGroundColorBaseHue < 0 {
newBackGroundColorBaseHue = 1 + newBackGroundColorBaseHue
} else if newBackGroundColorBaseHue > 1 {
newBackGroundColorBaseHue = 1 - newBackGroundColorBaseHue
}
backGroundColorHue = newBackGroundColorBaseHue
// 一定の確率でドラッグ周辺に泡を追加する
if Float.random(in: 0..<1) < 0.7 {
let newBubble = Bubble.makeBubble(x: value.location.x, y: value.location.y)
bubbles.append(newBubble)
}
// 泡が少なかったから確率低めにしてもう少し足してみる
if Float.random(in: 0..<1) < 0.4 {
let newBubble = Bubble.makeBubble(x: value.location.x, y: value.location.y)
bubbles.append(newBubble)
}
}
/// ドラッグ終了時
func updateOnDragEnd() {
previousBackGroundColorHue = backGroundColorHue
}
/// データの更新
func update(now: Date) {
// 途中で配列から削除することもあるので、reversedで大->小でまわす
// そうしないと配列の範囲外になってクラッシュする
for i in (0 ..< bubbles.count).reversed() {
// データを更新
var bubble = bubbles[i]
bubble.update()
// 画面の外で見えなくなったら配列から削除
if bubble.y < -(bubble.radius * 2) {
bubbles.remove(at: i)
// 通常は元のデータと入れ替える
} else {
bubbles[i] = bubble
}
}
updateFPS(now: now)
}
/// フレームレートを更新
func updateFPS(now: Date) {
let fpsDouble = 1 / now.timeIntervalSince(previousUpdateDate)
fps = Int(floor(fpsDouble))
previousUpdateDate = now
}
}
struct ContentView: View {
/// モデル
@State private var viewModel = ViewModel()
var body: some View {
TimelineView(.animation) { timelineContext in
GeometryReader { geometory in
ZStack {
Canvas { canvasContext, size in
// 描画
drawCanvas(geometory: geometory, canvasContext: canvasContext, size: size)
// データの更新。メインスレッドで行う
Task.detached { @MainActor in
viewModel.update(now: timelineContext.date)
}
}
.ignoresSafeArea()
.gesture(onCanvasDragGesturue(canvasSize: geometory.size))
// フレームレート(FPS)と泡の数の表示
VStack(alignment: .leading, spacing: 0) {
Text("FPS: \(viewModel.fps), Bubbles: \(viewModel.bubbles.count)")
.foregroundColor(.white)
.font(.system(size: 21))
.fontWeight(.bold)
.padding(.leading, 20)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
}
}
}
}
}
/// Canvasドラッグ時
func onCanvasDragGesturue(canvasSize: CGSize) -> some Gesture {
// 基準座標を.globalにするとSafeAreaがあっても一番上が0になる
// .localだとSafeAreaから上はマイナス座標になる
DragGesture(coordinateSpace: .global)
.onChanged { value in
viewModel.updateOnDragChange(value, canvasSize: canvasSize)
}
.onEnded { value in
viewModel.updateOnDragEnd()
}
}
/// Canvasを描画する
func drawCanvas(geometory: GeometryProxy, canvasContext: GraphicsContext, size: CGSize) {
// 背景の描画
canvasContext.fill(
Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 0),
with: .linearGradient(viewModel.backgroundGradient, startPoint: .zero, endPoint: .init(x: 0, y: size.height))
)
// 泡の描画
viewModel.bubbles.forEach { bubble in
canvasContext.fill(Path(ellipseIn: CGRect(x: bubble.x - bubble.radius, y: bubble.y - bubble.radius, width: bubble.radius * 2, height: bubble.radius * 2)), with: .color(Color(white: 1, opacity: bubble.opacity)))
}
}
}
説明
経緯
Canvasをリアルタイムに更新して描画できないのかな?と思い調べてみました。
そうしたら、こちらのページが見つかりました
やっていることとしては、CanvasとTimelineViewを組み合わせて更新するというものになっていました。
ですので同じようにできるか、試してみました
全体構成
- TimelineViewの中にCanvasを設置
- その中で以下を毎フレームごとに行います
- 描画
- データの更新
- データの更新はメインスレッドの非同期処理で行います(そうじゃないと画面が固まった!)
Task.detached { @MainActor in
背景の描画
- 背景の色はHSB形式で指定
- H(Hue=色相)を変更することで色を変えている
- 色相は普通は360度で表されて、0度と360度はつながっている輪の構造。=色相環
- ただしSwiftUIでは 0-360ではなく0-1になっている
- 0より下になったときは1に戻る。1より上になったときは0に戻るように処理している
泡
- ドラッグ中にドラッグ位置の周辺に一定確率で泡を発生させる
- そのとき配列にデータを追加
- 毎フレームごとに泡のデータを更新
- サインカーブは0-2πのラジアンを基準に動かすので、2πを超えたら0に戻す
- 画面の外に見えなくなったら、配列から削除する
フレームレート(FPS)の計算
- タイムラインから更新するたびにdateが取得できる
- ひとつ前のフレームとの差分を使ってFPSを計算
感想とか
- 常に60fpsが出ているので、思ったよりも描画負荷は低い
- SwiftUIでもこのくらいのことができるんだなーと思いました